Skip to content

feat(core): add masked_connector_response with per-connector key allowlist - #2050

Open
shuklatushar226 wants to merge 15 commits into
mainfrom
feat/unmasked-connector-response
Open

feat(core): add masked_connector_response with per-connector key allowlist#2050
shuklatushar226 wants to merge 15 commits into
mainfrom
feat/unmasked-connector-response

Conversation

@shuklatushar226

@shuklatushar226 shuklatushar226 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Description

Adds unmasked_connector_response: a sibling to raw_connector_response carrying the same gateway reply with every key preserved and every value masked unless that connector's configured list names it.

Adyen replied                          masked_connector_response
─────────────                          ─────────────────────────
pspReference:  RBD5GN77JV3MJT75   ───▶  "RBD5GN77JV3MJT75"   ← in config
resultCode:    Refused                  "Refused"            ← in config
cardSummary:   1111                     "***"
expiryDate:    10/2030                  "***"
additionalData { …51 keys… }            all 51 KEYS visible, values "***"

It is a plain String, not a Secret — it is already sanitized, and wrapping it would reproduce the bug being fixed. The body is emitted whole; there is no truncation.

The whole feature sits behind a Cargo feature, connector-response-masking, which is off by default. A build that does not opt in does not contain the masking code at all, so no configuration — file, env var or runtime patch — can make it emit connector response content. The release image does not enable it; config/development.toml turns it on for local work.

Motivation and Context

raw_connector_response is Option<Secret<String>> (domain_types/src/connector_types.rs), so masked_serialize renders the whole body as one placeholder — literally *** alloc::string::String ***. That leaves two options, both useless:

  • expose everything → PAN/CVV/tokens in logs
  • expose nothing → the placeholder

And response.body on the outgoing span only shows the typed struct, so any field a transformer did not model is invisible. When a gateway returns something unexpected, it cannot be diagnosed from logs at all.

Design notes

Masking happens during serialization, not by mutating the parsed tree. The obvious implementation — walk the tree and overwrite masked values — cannot see scalars inside arrays, because they reach the walker with no key in scope. {"tags":["4111111111111111"]} would have gone out in the clear. Carrying the parent-key decision into elements makes that leak impossible by construction, and also drops one pass over the tree plus an allocation per masked field.

Output keeps the input format. JSON in / JSON out; XML in / XML out; form-encoded in / form-encoded out. XML is copied event by event rather than round-tripped through serde_json::Valuequick_xml::de folds attributes into @name keys and text into $text keys, so a round trip emits literal <$text> elements and loses namespaces, the root name, and the attribute/element distinction.

Config is per-connector only, with no global list. A field name safe on one gateway is not necessarily safe on another — a global list would make authCode safe everywhere because it is safe on one connector.

Keyed by ConnectorEnum. An unknown connector name aborts startup naming the bad key rather than silently masking everything:

Unable to deserialize application configuration:
  connector_response_masking.connector_keys: unknown connector `paysafee`

Keys parse through FromStr rather than the serde derive, because #[strum(serialize_all = "snake_case")] governs FromStr/Display only, and the config crate lowercases env-var keys — CS__…__CONNECTOR_KEYS__ADYEN arrives as adyen and could never match Adyen. Same route WebhookSourceVerificationCall takes.

Gated by its own enabled flag, independent of return_raw_connector_data. Production can keep raw capture off and still get the safe view — that is the main reason the feature exists.

Gated at compile time as well, not only at runtime. A runtime flag can be flipped by a misconfiguration, an env var or a config patch. This code turns raw gateway bytes into a returnable string, so the guarantee should be structural rather than procedural. Under the feature gate: the connector_response_masking module (and quick-xml / serde_urlencoded, which nothing else in domain_types uses, so they become optional deps), the Config section, the EventProcessingParams field, and record_masked_connector_response with its two call sites. The feature chains domain_types → external-services → ucs_env → grpc-server, so enabling it on the server is enough.

The runtime enabled flag is deliberately kept alongside it: the build decides whether the code exists, config still lets an operator switch it off without a redeploy, and the per-connector allowlists need config regardless.

The boundary stops at the proto. prost generates masked_connector_response from the .proto unconditionally, so every response literal has to supply a value in any build. That field and the inert Option<String> on the nine flow-data structs therefore remain compiled in and are simply always unset when the feature is off. Gating them would mean ~90 more #[cfg] lines, a build-time proto rewrite, and the same tax on every future flow-data struct — for 24 inert bytes.

Additional Changes

  • This PR modifies the API contract
  • This PR modifies application configuration/environment variables

API contractoptional string masked_connector_response added to 23 response messages (21 in proto/payment.proto, 2 in proto/frm.proto), each at the next free tag. Additive and backward compatible; no tag is reused. Note the type is string, not SecretString, unlike its neighbour.

Configuration — new [connector_response_masking] section in config/development.toml, config/sandbox.toml, config/production.toml:

[connector_response_masking]
# `true` in development.toml; `false` in sandbox.toml and production.toml, so a
# deployment opts in once it has chosen its key lists.
enabled = false

# Whether to ALSO write the masked view to our own logs. `enabled` already returns it
# to the caller; this is the extra copy we retain, so it stays off outside development.
log_to_span = false

# Per-connector unmask lists, comma-separated and case-insensitive.
# A connector with no entry gets every value masked (keys still visible).
# Naming a key reveals only that key's own value: an object below it is re-decided key
# by key, and an array below it stays masked, since its elements have no key to name.
# Card/CVV/token-like key names stay masked regardless.
# A body that is not JSON, XML or form-encoded is replaced by a size-only stub.
#
# Only one entry is seeded, as a worked example for testing. Add a line per
# connector as you need its fields visible; an unknown connector name here will
# abort startup rather than be ignored.
[connector_response_masking.connector_keys]
adyen = "pspreference,resultcode,merchantreference,refusalreason,eventcode,success"

Only adyen is seeded because it is the one entry verified against a live sandbox call — it is an example, not a recommended set.

Env-overridable per connector without a code change:

CS__CONNECTOR_RESPONSE_MASKING__ENABLED=true
CS__CONNECTOR_RESPONSE_MASKING__CONNECTOR_KEYS__ADYEN=pspreference,resultcode

No new dependencies beyond moving quick-xml (already in the lockfile) into domain_types.

Build configuration — a new Cargo feature connector-response-masking, declared on domain_types, external-services, ucs_env and grpc-server, and absent from every default build:

cargo build -p grpc-server                                       # masking code not compiled in
cargo build -p grpc-server --features connector-response-masking # opt in

The Dockerfile is unchanged — it already pins --features kafka,connector-request-kafka,otel, so the release image excludes masking by construction; a comment there records that the omission is deliberate. CI's two cargo nextest run invocations now pass the feature, or the module's 40 unit tests would silently stop running; cargo clippy --all-features already covered the feature-on build.

How did you test it?

Unit tests plus a manual end-to-end run against the real Adyen sandbox. The feature gate adds a second axis: every behaviour is checked with the feature both off and on.

40 unit tests in connector_response_masking.rs. The module removed in b4431bc is restored and ported to the current API, and extended to cover the four masking bypasses found in review. Those 11 new tests were written before the fixes and each one failed on the parent commit, printing the PAN in the clear — so they test the leak, not just the fix.

Check Result
Field populated on a live authorize ✅ listed fields visible, 51 additionalData keys present with ***
Body returned whole ✅ 1474 bytes, no truncation
Raw vs masked side by side cardSummary = 1111 raw, *** masked
IndependenceRETURN_RAW_CONNECTOR_DATA=false + masking on ✅ raw absent, masked view present (the production shape)
Env override …CONNECTOR_KEYS__ADYEN=pspreference ✅ narrowed to that one key
Typo paysafee in TOML ✅ startup aborts naming the bad key
4xx path (forced 401) ✅ masked body still recorded
Negative — PAN 4111… / CVC anywhere in logs 0 occurrences

Coverage spans: keyed masking and the denylist override; the invariant that an allowed key never reveals a subtree (objects re-decide per key, arrays stay masked); bodies with no key to gate on (root array, bare scalar, text/plain, CSV, binary); XML text, attributes, CData, comments, DOCTYPE and PIs; form bodies that are BOM-prefixed, newline-separated or not pair-shaped; and config load accepting a name from each of the five connector enums while rejecting a typo.

cargo check --workspace (no warnings), cargo fmt --all --check and cargo clippy on the touched crates are clean.

Compile-time gate, end to end

Live sandbox calls through a locally-run server, feature off and on:

Run Feature Config Result
Adyen authorize off development.toml as shipped (enabled = true, adyen allowlisted) CHARGED; masked_connector_response absent
Adyen authorize off env forces ENABLED=true, LOG_TO_SPAN=true and an allowlist CHARGED; still absent, 0 response.masked_body span records
Adyen authorize on development.toml allowlist 3 listed keys in the clear, 45+ other values ***
Adyen authorize on env ENABLED=false absent — the runtime kill switch still works on a build that contains the code
Adyen authorize on env allowlist swapped to authCode,cardSummary,cardBin,expiryDate those revealed; expiryDate stays *** (denylist beats the operator's list); the TOML keys go dark, confirming the override replaces rather than merges
Elavon authorize on one XML element allowlisted <errorCode>4025</errorCode> revealed, siblings ***, output still XML
Fiuu PSync on status,tranID allowlisted TranID=31530063 revealed, VrfKey and Amount ***, output still form-encoded

The two off rows are the point of the change: the same env overrides that drive the on rows cannot make the default build emit anything. Elavon's sandbox credentials were rejected (4025), so the XML path was exercised against its error body rather than a success body; Fiuu PSync covers the newline-separated form shape that mask_form normalises.

Build and lint, both configurations:

Check feature off feature on
cargo check -p grpc-server
cargo check --workspace --all-targets ✅ no warnings
cargo clippy --all-targets on the touched crates ✅ clean ✅ clean
cargo test -p domain_types connector_response_masking 0 tests (module compiled out) 40 passed
cargo tree -p domain_types --depth 1 neither parser present quick-xml + serde_urlencoded present

cargo fmt --all --check is clean.

Notes for reviewers

  • The const denylist (cardnumber, cvv, token, …) overrides config so a careless entry cannot expose a PAN. authorization is deliberately exact-match rather than substring, so it does not permanently block authorizationCode — a routine field operators will want visible.
  • generate_refresh_payment_method_response assigns proto fields rather than building a struct literal, so a missing field raises no E0063. That path was silently unpopulated until an unused-variable warning surfaced it; worth a second pair of eyes on whether any similar assignment-style site was missed (grep -rn '\.raw_connector_response = ').
  • XML mixed content (<a>x<b>y</b>z</a>) over-masks the trailing text, since the enclosing element is no longer tracked after </b>. Conservative direction, and mixed content does not occur in payment APIs.
  • VerifyRedirectResponse sets the field to None: it is assembled from a redirect verification rather than a connector HTTP call, so no masked body exists.
  • A body matching no structured format is replaced by {"_format":"unparsable","_bytes":N} rather than emitted. This is all-or-nothing: a real form body with a valueless segment (a=1&flag&b=2) is stubbed whole. No gateway is known to send that shape.
  • With truncation removed, the emitted string is bounded only by what the gateway sends. Masking shrinks bodies substantially (every unlisted value becomes ***), but if a pathological response ever floods the span/gRPC/Kafka payloads, a cap can be reintroduced as a config-only field.
  • Enabling external-services/connector-response-masking without grpc-server's own feature gives a missing-field error at the 18 EventProcessingParams literals. The forwarding chain keeps every normal path and --all-features consistent; only a hand-crafted feature combination hits it. #[cfg] on those struct-expression fields also means a future call site that forgets the gate fails to compile rather than drifting silently.
  • The bulk of the diff is mechanical — 23 proto fields, 9 trait implementors, ~53 construction sites — driven from rustc's own error output rather than by hand.

🤖 Generated with Claude Code

…lowlist

`raw_connector_response` is an `Option<Secret<String>>`, so `masked_serialize`
collapses the entire gateway reply into a single placeholder
(`*** alloc::string::String ***`). That leaves only two options: expose the whole
body, which carries PAN/CVV/tokens, or see nothing at all. Fields a transformer
never modelled are invisible either way, since `response.body` only ever shows
the typed struct.

Add a sibling field carrying the same body with every key preserved and every
value masked unless that connector's configured list names it. It is a plain
`String`, not a `Secret`, because it is already sanitized — wrapping it would
reproduce the bug being fixed.

- masking happens during serialization rather than by mutating the parsed tree.
  A walk-and-overwrite pass cannot see scalars inside arrays (they arrive with no
  key in scope), so `{"tags":["4111111111111111"]}` would leak. Carrying the
  parent-key decision into elements makes that impossible by construction, and
  drops a pass plus one allocation per masked field.
- output keeps the input format: JSON in/JSON out, XML in/XML out (copied event
  by event so namespaces, attributes and ordering survive), form in/form out.
- config is per-connector only. A field name safe on one gateway is not
  necessarily safe on another, so there is deliberately no global list. A
  connector with no entry gets every value masked with every key still visible,
  which is both the safe default and how you discover names to configure.
- keyed by `ConnectorEnum`, so an unknown connector name aborts startup naming
  the bad key instead of silently masking everything. Keys parse via `FromStr`,
  not the serde derive, because the config crate lowercases env-var keys.
- gated by its own `enabled` flag, independent of `return_raw_connector_data`,
  so production can keep raw capture off while retaining the safe view. That is
  the main reason the feature exists.

Verified against the Adyen sandbox: listed fields visible, all 51
`additionalData` keys present with masked values, no PAN or CVC anywhere in the
logs, and the field still populated on the 4xx path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shuklatushar226
shuklatushar226 requested review from a team as code owners August 4, 2026 08:20
shuklatushar226 and others added 3 commits August 4, 2026 13:53
Drops the test module and the `toml` dev-dependency it required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The emitted body is now returned whole. `max_bytes` carried the cap value in
two places — the TOML files and a `DEFAULT_MAX_BYTES` const — which could
silently disagree, and the requirement is the full response regardless. Removing
truncation leaves nothing to configure, so the field, the const pair and `cap()`
all go rather than being reworked into a config-only value.

Also fixes a gap the compiler could not catch: `generate_refresh_payment_method_response`
assigns proto fields rather than building a struct literal, so the missing
`unmasked_connector_response` never raised E0063. `PaymentMethodServiceRefreshResponse`
was silently never populated on either the success or error path.

Seed only `adyen` in the sample config. The other five lists were written from
knowledge of each gateway's response shape and never verified against a live
call; shipping guesses as config invites people to trust them. Removed
connectors fall into the "no entry" case — every value masked, every key still
visible.

Verified against the Adyen sandbox: 1474 bytes returned whole with no truncation
marker, listed fields visible, 51 additionalData keys present with masked values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three checks were red because local verification was weaker than CI's.

- Run Tests: `cargo check --workspace` does not compile test targets, so 16
  `PaymentFlowData` literals in razorpay/calida/adyen `test.rs` were never seen.
  Adding `unmasked_connector_response: None` to each. `--all-targets` is what
  makes "let rustc enumerate every miss" actually hold.
- Clippy: removing `max_bytes` left the manual `Default` identical to what the
  derive produces, tripping `derivable_impls` under `-D warnings`. Dropped the
  impl and added `Default` to the derive list.
- Spell check: `unparseable` -> `unparsable`, matching how the repo already
  spells it in kount and affirm rather than adding a `.typos.toml` exception.

Verified with CI's own commands: `cargo check --workspace --all-targets`,
`cargo clippy --workspace --all-targets -- -D warnings` (exit 0),
`cargo test --workspace --no-run` (exit 0), `cargo fmt --all -- --check`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shuklatushar226
shuklatushar226 requested a review from a team as a code owner August 4, 2026 10:22
shuklatushar226 and others added 4 commits August 4, 2026 16:52
CI builds the merge of this branch with main, not the branch alone. Four
commits landed on main meanwhile — Plaid authenticator, currency conversion,
PayPal sender_payment_instrument_id — adding call sites this branch had never
seen. That is why CI failed while local checks passed.

- merge origin/main
- `EventProcessingParams` literal added by main (payments.rs, PaymentMethodToken
  flow) now carries `connector_response_masking`
- 3 `PaymentFlowData` / `MerchantAuthenticationFlowData` literals in the new
  plaid tests carry `unmasked_connector_response`
- trim comments in connector_response_masking.rs from a 23% ratio to 12%
  (repo norm is 8-10%): design rationale lives in the PR description, not in
  nine-line doc essays. Section-divider banners dropped — only 12 files in the
  repo use that style. Kept the non-obvious ones: why array elements inherit
  the mask decision, why XML whitespace is never masked, why nothing is
  rebuilt after a patch.

Verified with CI's own commands, including the `--all-features` flag missing
from earlier rounds:
  RUSTFLAGS="-D warnings" cargo check --workspace --all-features --all-targets
  cargo clippy --workspace --all-features --all-targets -- -D warnings  (exit 0)
  cargo test -p integration-tests --all-features --lib --no-run
  cargo test -p composite-service --all-features --test composite_request_schema_check --no-run
  cargo fmt --all -- --check

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`is_always_masked` normalises the key (allocating a String) then scans it
against 16 constant patterns. It ran first, for every field — including the
majority that are not allowlisted and get masked regardless.

Swapping the two is behaviour-preserving: the denylist only ever overrides a
key the allowlist would have revealed, so for a key absent from the allowlist
its verdict never mattered. On the verified Adyen response — 51 additionalData
fields, 1 allowlisted — the denylist scan drops from 51 executions to 1.

Also sharpens the denylist's documented scope. It covers full PAN, CVV, expiry
and credentials; it does not cover truncated values such as cardSummary, last4
or cardBin. Those are not PAN, they appear on receipts, and a connector that
needs them for reconciliation can name them in its own list. The previous
wording implied broader coverage than the patterns actually provide.

Verified against the Adyen sandbox: `expiryDate` placed deliberately in the
allowlist still returns "***", proving the denylist still overrides config; the
shipped-config baseline is byte-identical to before the change, with no PAN
anywhere in the body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking every RequestContent variant against the response path found two
defects. The masker reads the pre-preprocessing bytes — what the gateway put on
the wire — which is right for a diagnostic but means it must cope with raw
shapes that 10+ connectors normalise away in preprocess_response_bytes.

Newline-separated bodies could leak. Fiuu replies with `key=value` pairs
separated by newlines, not `&` (see connectors/fiuu.rs:176). serde_urlencoded
splits only on `&`, so the whole body folded into the first pair's value:

    key='Status'  value='00\nTranID=…\nCardNo=411111xxxxxx1111\nAmount=…'

An operator allowlisting `status` — the natural first choice — would have
revealed that value and everything inside it. Normalising literal newline bytes
to `&` before parsing keeps each pair distinct. Percent-encoded %0A inside a
genuine form value is untouched, so real urlencoded bodies are unaffected.
Verified end-to-end against a mock serving Fiuu's exact shape: the masked body
is now `Status=00&TranID=***&CardNo=***&Amount=***&Domain=***`.

BOM defeated every parser. Authorize.Net prefixes responses with a UTF-8 BOM and
strips it in preprocess_response_bytes — which runs inside handle_response_v2,
after masking. serde_json rejected the BOM and every such response silently
yielded the `_format` stub. Stripping it before `detect` also fixes the
no-Content-Type case, where the BOM made the first meaningful byte 0xEF and
misrouted sniffing to the form parser.

Multipart and binary bodies still yield the size-only stub, deliberately:
RequestContent::FormData is only ever built request-side, and binary cannot be
masked meaningfully.

Adyen unchanged: pspReference, resultCode, refusalReason, merchantReference
visible, 51 additionalData keys, everything else masked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// rejects. The connector strips it in `preprocess_response_bytes`, but that runs inside
// `handle_response_v2` — after this point. Must precede `detect`: a BOM makes the first
// meaningful byte `0xEF`, so sniffing would misroute before reaching the `{`.
let body = body.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(body);

@AmitsinghTanwar007 AmitsinghTanwar007 Aug 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a existing function for this functionality of removing prefixes i think we can use it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the duplication, though strip_bom_and_convert_to_string (service.rs:1688) can't be called from here:

  • it's private to external-services, and domain_types can't depend on that crate — the arrow runs the other way (external-services/Cargo.toml:46);
  • signature mismatch: it's &[u8] -> Option<String>, but this point needs &[u8] -> &[u8]. detect() sniffs raw bytes and mask_xml/mask_form consume bytes, so routing through String would force UTF-8 validation plus a to_vec() copy before format detection, and drop non-UTF-8 bodies to the unparsable stub.

Extracted strip_utf8_bom(&[u8]) -> &[u8] into common_utils::bytes_utils instead — both crates already depend on common_utils. Both call sites now go through it, and it strips repeated BOMs to match the trim_start_matches behaviour it replaced, so service.rs is unchanged behaviourally.

Comment on lines +132 to +152
const ALWAYS_MASKED_SUBSTRING: &[&str] = &[
"cardnumber",
"cardnum",
"accountnumber",
"cvv",
"cvc",
"cvn",
"expmonth",
"expyear",
"expirydate",
"secret",
"token",
"password",
"signature",
"apikey",
];

/// Never revealed, matched **exactly**. `authorization` is here rather than above so it does not
/// also block `authorizationCode`, which operators legitimately reveal.
const ALWAYS_MASKED_EXACT: &[&str] = &["authorization"];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should own this hardcoded denylist — we can't realistically know every sensitive field name across all connectors. One connector calls it pan, another
primaryAccountNumber, another encryptedPaymentData. This list will always be playing catch-up.

Right now the allowlist is static config so it's manageable, but when this becomes dynamic (client-controlled), we should not be logging unmasked_connector_response on
our side at all — just return it in the gRPC response. That way if someone configures a bad allowlist, the unmasked value only shows up in their system, not ours.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two parts here, answering them separately.

On the denylist — the premise is right, but it isn't the mechanism doing the work. The default is mask-everything, so an unknown sensitive field name is masked because it was never allowlisted, not because the denylist caught it. ALWAYS_MASKED_SUBSTRING/_EXACT only ever fire on keys an operator explicitly allowlisted, which makes them a backstop against a careless config entry rather than the primary defence. An incomplete list therefore costs nothing the default doesn't already cover, so I'd keep it as-is.

On the logging — taking this now rather than at the dynamic-config milestone, because the containment argument already holds under static config: a typo in the TOML writes to our logs today. Added log_to_span, gated separately from enabled:

[connector_response_masking]
enabled     = true   # populate the field on the gRPC response
log_to_span = false  # ALSO record response.unmasked_body on our span

true in development.toml, false in sandbox.toml and production.toml. The caller always gets the masked view back on the response; a copy only lands in our own logs where that's explicitly turned on, so a bad allowlist entry stays contained to whoever configured it.

Comment on lines +346 to +349
let Ok(connector) = domain_types::connector_types::ConnectorEnum::from_str(connector_name)
else {
return;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add debug log here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a log here would have papered over a live bug — the comment above this branch is wrong.

connector_name doesn't come from ConnectorEnum. It comes from ConnectorVariant::get_connector_name() (connector_types.rs:403), which stringifies five enums. Three names have no ConnectorEnum counterpart, so from_str fails and the function returns before populating anything:

Name Source enum
Interpayments SurchargeConnectorEnum
Deutschebank PayoutConnectorEnum
Plaid AuthenticatorConnectorEnum

The branch is reachable — set_unmasked_connector_response is implemented for surcharge_types.rs, payouts_types.rs and merchant_authentication_flow_data.rs. And the breakage was inconsistent rather than uniform: Loonio, Paypal, Itaubank, Worldpayxml, Cybersource (payout) and Kount (FRM) collide with ConnectorEnum names and worked fine, so behaviour differed per connector inside the same flow with no signal.

Same root cause hit config load: deserialize_connector_keys also parsed keys with ConnectorEnum::from_str, so deutschebank = "..." in a TOML aborted startup for a perfectly real connector.

Rather than logging the silent exit, I removed the re-parse. Ingress already validates the name per flow family (connector_variant_from_metadata, metadata.rs:113x-connector against ConnectorEnum, x-payout-connector against PayoutConnectorEnum, and so on), so the string arriving here is already canonical. It's now used directly as a lookup key, and config-load validation accepts a name that any of the five enums recognises. A lookup miss degrades to the empty allowlist — which is already the correct default for an unconfigured connector — so there's no failure branch left to log.

Verified: with interpayments/deutschebank/plaid as config keys the server boots; paysafee still aborts with unknown connector `paysafee` .

… re-parse

Three review comments on #2050. The middle one turned out to sit on a live bug.

record_unmasked_connector_response re-parsed an already-validated connector name
against the wrong authority. Ingress resolves a connector per flow family, each
against its own enum — x-connector via ConnectorEnum, x-payout-connector via
PayoutConnectorEnum, and so on (metadata.rs:113) — and rejects an unknown name
with InvalidDataFormat before any connector call. Masking then asked "is it a
payment connector?" when the only question it needed answered was "what is its
name?", and treated the mismatch as absence:

    let Ok(connector) = ConnectorEnum::from_str(connector_name) else { return; };

Three ingress-valid connectors have no ConnectorEnum counterpart, so the field
was silently None for them: Interpayments (surcharge), Deutschebank (payout),
Plaid (authenticator). The breakage was partial rather than uniform — Loonio,
Paypal, Itaubank, Worldpayxml, Cybersource (payout) and Kount (FRM) collide with
ConnectorEnum names and worked — so behaviour differed per connector inside the
same flow with no signal. The comment above the branch claimed it always
round-trips; it does not.

Same root cause reached config load: deserialize_connector_keys also parsed keys
with ConnectorEnum::from_str, so `deutschebank = "..."` aborted startup naming a
perfectly real connector.

Rather than logging the silent exit, the re-parse is gone. connector_keys is
keyed by the snake_case name, which is canonical by the time it arrives, and
config-load validation accepts any name the five enums recognise. A lookup miss
degrades to the empty allowlist — already the right outcome for an unconfigured
connector — so no failure branch remains. hyperswitch::Connector was considered
as a single authority and rejected: it omits 15 UCS connectors including Kount,
and is a pinned git snapshot.

Verified against the built binary: with interpayments/deutschebank/plaid as
config keys the server boots; `paysafee` still aborts with
"unknown connector `paysafee`".

Also in this commit:

log_to_span gates the span record independently of enabled, so the caller is
sent unmasked_connector_response without a copy being retained in our logs. The
containment argument holds under static config too — a typo in the TOML writes
to our logs today. true in development, false in sandbox and production.

strip_utf8_bom moves to common_utils::bytes_utils, backing both the masker and
strip_bom_and_convert_to_string. The existing helper could not be reused in
place: it is private to external-services, domain_types cannot depend on that
crate, and it returns Option<String> where the masker needs &[u8] -> &[u8]
before detect() sniffs raw bytes. Repeated BOMs are still stripped, so
service.rs is unchanged behaviourally.

The const denylist stays. Default is mask-everything, so an unknown sensitive
field is masked because it was never allowlisted; the denylist only fires on
keys an operator explicitly allowlisted, making it a backstop rather than the
primary defence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// Selectively-masked view of the connector response: every key preserved,
// values masked unless the connector's configured list names them. Already
// sanitized, hence `string` and not `SecretString`.
optional string unmasked_connector_response = 25;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all these string in proto are masked_connector_response right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and the name was backwards — renamed to masked_connector_response in 709b8509.

The field holds the connector reply with every value masked (***) except the ones an operator allowlisted for that connector, so unmasked_ promised the opposite of what it carries. Worse given the neighbour: these structs already have raw_connector_response, which is the verbatim unmasked body. Two fields, raw_ and unmasked_, and the safe one was the one whose name promised no masking.

Pure identifier rename, no behavioural change — 260 occurrences plus 5 of the response.unmasked_body span field, across 23 files:

unmasked_connector_response -> masked_connector_response
unmasked_body               -> masked_body

Proto tags are untouched, so the wire format is unchanged and only the generated accessor names move. Not a compatibility break either, since these fields don't exist on main — this PR introduces them.

Left alone deliberately: raw_connector_response (394 occurrences — the genuinely unmasked sibling), unmasked_headers (unrelated log config), and mask_connector_response / ConnectorResponseMaskingConfig / MASKED, which already describe masking correctly.

One side effect worth flagging: this also turns SDK Tests green, but by luck rather than by fixing anything. That job was failing because protoc emits every payment.proto message into a single outer class (java_multiple_files is unset) and the generated Payment.java hit 20,973,967 bytes against the Kotlin compiler's embedded IntelliJ cap of 20,971,520 — over by 2,447 bytes, surfacing as FileTooBigException → "Internal compiler error". The shorter identifier reclaims 3,006 bytes, landing ~559 bytes under. main alone sits ~1% below the same ceiling and arduino/setup-protoc@v3 is unpinned in ci.yml, so this will recur. Pinning protoc / raising kotlin.daemon.jvmargs is worth doing separately on main.

…r_response

Review feedback from @jarnura on payment.proto: the field name is backwards.

It carries the connector's reply with every value masked ("***") except those an
operator allowlisted per connector — the masked view, not an unmasked one. Read
literally, "unmasked_connector_response" promises masking has been removed, which
is the opposite of what it holds.

That mattered more than usual because of what sits beside it. The same structs
already carry raw_connector_response, which IS the verbatim unmasked body. So the
file offered a reviewer two fields, `raw_` and `unmasked_`, and the safe one was
the one whose name promised no masking. Anyone reasoning about PII exposure from
field names alone would have picked wrong.

Pure identifier rename, no behavioural change:

    unmasked_connector_response -> masked_connector_response   (260)
    unmasked_body               -> masked_body                 (5)

Proto field tags are untouched, so the wire format is unchanged; only the
generated accessor names move. Not a compatibility break — these fields do not
exist on main, they are introduced by this PR.

Deliberately left alone: raw_connector_response (394 occurrences, the genuinely
unmasked sibling), unmasked_headers (11, unrelated log config), and
mask_connector_response / ConnectorResponseMaskingConfig / MASKED, all of which
already describe masking correctly.

Incidentally unblocks SDK Tests, which should not be relied on. That job fails
because protoc emits every payment.proto message into one outer class
(java_multiple_files is unset) and the generated Payment.java reached 20,973,967
bytes against the Kotlin compiler's embedded IntelliJ cap of 20,971,520 — over by
2,447 bytes, surfacing as FileTooBigException -> "Internal compiler error". The
shorter identifier reclaims 3,006 bytes across every generated accessor, constant
and descriptor string, landing ~559 bytes under the cap. That is a coincidence,
not a fix: main alone sits ~1% below the same ceiling, and arduino/setup-protoc@v3
is unpinned in ci.yml so generated size drifts upward with each protoc release.
Pinning protoc and raising kotlin.daemon.jvmargs remains open, and belongs on main
rather than buried in a review-feedback PR.

Verified: zero residual occurrences, guard-string counts unchanged, cargo check
--workspace --all-targets / clippy / fmt clean, test_config_override green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jarnura
jarnura previously approved these changes Aug 6, 2026
Comment on lines +383 to +400
fn detect(content_type: Option<&str>, body: &[u8]) -> Option<Format> {
if let Some(content_type) = content_type {
let lowered = content_type.to_ascii_lowercase();
if lowered.contains("json") {
return Some(Format::Json);
}
if lowered.contains("xml") || lowered.contains("soap") {
return Some(Format::Xml);
}
if lowered.contains("x-www-form-urlencoded") {
return Some(Format::Form);
}
}

match body.iter().find(|byte| !byte.is_ascii_whitespace()) {
Some(b'{' | b'[') => Some(Format::Json),
Some(b'<') => Some(Format::Xml),
Some(_) => Some(Format::Form),

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anything that isn't JSON/XML/SOAP-shaped falls through to Format::Form, and mask_form round-trips arbitrary bytes as a single (whole_body, "") pair — so the body comes back out as a key, unmasked. A declared text/plain lands here too, since the Content-Type branch above doesn't recognise it.

"Transaction declined for card 4111111111111111"  ->  "Transaction+declined+for+card+4111111111111111=***"
"1,4111111111111111,SUPERSECRET"                  ->  "1%2C4111111111111111%2CSUPERSECRET=***"

The {"_format":"unparsable"} stub never fires because mask_form doesn't fail on arbitrary bytes. Can detect return None when neither the declared Content-Type nor the sniff matches a structured format?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and the stub was unreachable for the whole Form branch — serde_urlencoded never fails on arbitrary bytes, so mask_form always returned Some.

Both your examples reproduced exactly as written; they're now tests (a_plain_text_body_is_not_re_emitted_as_a_form_key, a_csv_shaped_body_is_not_re_emitted_as_form_keys, plus a binary body).

On where to put the check — I went one level lower than detect. A detect-only change still routes a declared application/x-www-form-urlencoded to the leaking path, and a connector declaring that header while returning something else is exactly the case worth catching. is_pair_shaped now gates mask_form, after newline normalisation so Fiuu's bodies are judged on their real pairs:

for segment in body.split(|byte| *byte == b'&') {
    if segment.is_empty() { continue; }          // trailing or doubled `&`
    match segment.iter().position(|byte| *byte == b'=') {
        None | Some(0) => return false,          // no `=`, or no key before it
        Some(_) => saw_pair = true,
    }
}

Non-form bodies now fall to {"_format":"unparsable","_bytes":N}. The sniff in detect stays, so a genuine form body with a missing Content-Type still works.

One trade-off worth naming: it's all-or-nothing, so a real form body with a valueless segment (a=1&flag&b=2) is stubbed whole rather than partly emitted. No gateway I know of sends that shape and losing a diagnostic is the cheaper failure, but say the word if you'd rather it degrade per-segment.

Fixed in d2eeb6a.

serde_json::to_string(&Masked {
value: &value,
keys,
mask: false,

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root is seeded mask: false, so a body with no key to gate on goes out in the clear:

["4111111111111111","x"]  ->  ["4111111111111111","x"]
"4111111111111111"        ->  "4111111111111111"

Both reach here — [ sniffs as JSON, and a bare string with Content-Type: application/json routes here too. Seeding the root mask: true fixes it; objects re-decide per key one level down, so nothing correctly revealed today regresses.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed both, and the second one is worse than it looks: the seed inverts the fail-closed premise the whole design rests on, and it's invisible on the one shape every existing test used. An object re-decides per key immediately, so {...} bodies masked correctly no matter what the root was seeded with.

Seeded true in mask_json. Your two cases and the sniffed variant ([ with no Content-Type) are now a_top_level_array_of_scalars_is_masked, a_bare_top_level_scalar_is_masked and a_top_level_array_is_reached_by_sniffing_too.

Fixed in d2eeb6a.

Comment on lines +241 to +247
Value::Array(items) => {
let mut state = serializer.serialize_seq(Some(items.len()))?;
for value in items {
state.serialize_element(&Self {
value,
keys: self.keys,
mask: self.mask,

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Array elements inherit mask: self.mask, so an allowlisted key passes false down to every element — which contradicts the invariant in the comment just above. With success in the Adyen list:

{"success":["4111111111111111"]}  ->  {"success":["4111111111111111"]}

Objects do behave as documented. Passing mask: true to elements restores the invariant; arrays under a masked key are unaffected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and taking it as you describe. The invariant is the right one to hold to: elements carry no key, so an operator can never name one — allowing success should reveal the scalar it names, not everything nested beneath it. Elements now always get mask: true.

Worth noting the two array rules pull in opposite directions and both are now pinned:

allowlist = [success]
{"success": true}                -> {"success": true}
{"success": ["4111111111111111"]} -> {"success": ["***"]}   <- this comment
{"tags": ["4111111111111111"]}    -> {"tags": ["***"]}      <- the original motivation

The first inheriting case was the reason mask was threaded into elements at all; it stays covered, since true is now passed unconditionally rather than inherited.

Also rewrote the Masked doc comment — it described mask as "carries the parent key's decision so array elements inherit it", which documented the bug rather than the intent. It now says mask is the decision for a value with no key of its own, cleared only by an allowlisted object entry.

Fixed in d2eeb6a.

}
}
// Declaration, comments, processing instructions, doctype: structural, copied as-is.
other => {

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch-all forwards Comment, DocType and PI verbatim, so a gateway that echoes the request inside a comment leaks it:

<r><!-- pan 4111111111111111 --><a>b</a></r>  ->  <r><!-- pan 4111111111111111 --><a>***</a></r>

Decl is structural and fine to copy through; these three aren't. Explicit arms that drop them, or emit *** in their place?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — reproduced verbatim, including inside <!DOCTYPE>'s internal subset and a PI:

<r><!-- pan 4111111111111111 --><a>b</a></r>
<!DOCTYPE r [<!ENTITY pan "4111111111111111">]><r><a>b</a></r>
<r><?echo 4111111111111111?><a>b</a></r>

Went with dropping rather than emitting ***, except for comments where the placeholder is cheap and keeps the signal that a comment was there. Decl passes through as you say.

The more useful part of the change is the default. It was other => write_event(other) — forward anything unrecognised — which is why three variants leaked without anyone choosing that. It's now _ => {}, so an event this match doesn't know is dropped. On the next quick-xml bump (pinned at 0.31.0) a new variant fails closed instead of silently joining the passthrough.

Fixed in d2eeb6a.

@@ -0,0 +1,436 @@
//! Builds `masked_connector_response`: the connector's reply with every key preserved and every

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b4431bc removed the unit tests and there's no #[cfg(test)] block, so Run Tests is green on 436 lines nothing exercises. Each leak I've flagged here reproduces in a few lines — minimum before merge:

  • keyless top-level JSON: array of scalars, bare scalar
  • arrays under an allowed key and under a denied key
  • text/plain, CSV-shaped and binary bodies
  • XML comments and DOCTYPE
  • BOM-prefixed and newline-separated form bodies, both special-cased in mask_form and neither guarded
  • an allowed key whose value is an object, to pin the invariant that holds

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in full. b4431bc is restored and ported — the config map is keyed by Box<str> now rather than ConnectorEnum, max_bytes is gone, and mask_connector_response takes &str — plus the cases you listed. 40 tests, and the toml dev-dependency comes back with them.

I wrote them before the fixes, against the code as it stood. 11 failed, one per bypass — which is the part of your comment that actually mattered, since a test written after a fix can't distinguish "passes because the fix works" from "passes because it never tested the leak". The failure output printed the PAN in the clear in every case:

a_bare_top_level_scalar_is_masked                          left: "\"4111111111111111\""
a_top_level_array_of_scalars_is_masked                     left: "[\"4111111111111111\",\"x\"]"
a_top_level_array_is_reached_by_sniffing_too
scalars_in_an_array_under_an_allowed_key_are_masked_too    left: "{\"success\":[\"4111111111111111\"]}"
a_plain_text_body_is_not_re_emitted_as_a_form_key          Transaction+declined+for+card+4111111111111111=***
a_csv_shaped_body_is_not_re_emitted_as_form_keys           1%2C4111111111111111%2CSUPERSECRET=***
a_declared_form_body_that_is_not_form_shaped_yields_the_stub   4111111111111111=***
a_binary_body_is_not_re_emitted_as_a_form_key
xml_comments_do_not_carry_their_content_through            <r><!-- pan 4111111111111111 --><a>b</a></r>
xml_doctype_does_not_carry_its_content_through             <!DOCTYPE r [<!ENTITY pan "4111111111111111">]>...
xml_processing_instructions_do_not_carry_their_content_through   <r><?echo 4111111111111111?><a>b</a></r>

The other 29 passed unchanged, which is the useful control — the ported coverage was not silently rewritten to fit the new behaviour.

Everything on your list is covered. Two notes on how:

  • Assertions are on absence of the PAN, not only on output shape. assert_eq! pins how a value is spelt; assert!(!out.contains(PAN)) pins the property. Both, where the shape is worth fixing.
  • Beyond your list: an allowlisted key holding a scalar (so the array rule can't over-mask), and config load accepting a name from each of the five enums.

BOM-prefixed and newline-separated form bodies both hold — a_bom_prefixed_body_is_still_parsed and a_newline_separated_form_body_is_masked_pair_wise.

Added in d2eeb6a.

/// (`ucs_interface_common::metadata::connector_variant_from_metadata`). No single enum spans all
/// five, so validating against one would reject real connectors: `interpayments`, `deutschebank`
/// and `plaid` have no [`ConnectorEnum`] counterpart.
fn is_known_connector(name: &str) -> bool {

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is_known_connector accepts AuthenticatorConnectorEnum, but the lookup key comes from ConnectorVariant::get_connector_name() (connector_types.rs:352) and ConnectorVariant has no authenticator arm — so an authenticator entry passes startup validation and then never matches. The config comments still tell operators plaid is valid. Drop it from this check and the comments, or add the arm to get_connector_name().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back on this one — ConnectorVariant does have an authenticator arm, so the lookup resolves.

// connector_types.rs:354
pub enum ConnectorVariant {
    Payment(ConnectorEnum),
    Surcharge(SurchargeConnectorEnum),
    Payout(PayoutConnectorEnum),
    Frm(FrmConnectorEnum),
    Authenticator(AuthenticatorConnectorEnum),   // :359
}

// connector_types.rs:403
pub fn get_connector_name(&self) -> String {
    match self {
        ...
        ConnectorVariant::Authenticator(conn) => conn.to_string(),   // :409
    }
}

It arrived with #1975 (423af1e0d, "add Plaid as authenticator connector"), which is an ancestor of origin/main and entered this branch in 0cd38c022 on Aug 4 at 16:52 IST — before your review, so it isn't a rebase artefact. Likely a stale local checkout; the line you cited (:352) doesn't match either the pre- or post-#1975 numbering.

The flow also terminates somewhere real: merchant_authentication_flow_data.rs:74 implements set_masked_connector_response, so an authenticator response does reach masking rather than resolving to a key nothing ever queries.

So is_known_connector keeps AuthenticatorConnectorEnum and the config comments keep plaid. Happy to be shown otherwise if you're seeing something I'm not — but on this HEAD, dropping it would reject a connector that works.

The adjacent claim underneath it is worth pinning, so config_accepts_a_name_from_any_connector_enum now loads one name from each of the five enums (adyen, interpayments, deutschebank, kount, plaid) and asserts each resolves — that's what would actually catch this class of drift, in either direction.

Comment on lines +62 to +65
/// Validate map keys via `FromStr`, not the serde derive: strum's `snake_case` applies to
/// `FromStr` only, and the config crate lowercases env-var keys. Same route as
/// `WebhookSourceVerificationCall`.
fn deserialize_connector_keys<'de, D>(

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[strum(serialize_all = "snake_case")] on ConnectorEnum (connector_types.rs:60) applies to the derived Display as well as EnumString — which is why get_connector_name() gives adyen and the seeded config key resolves at all. Validating via FromStr is still right; the reasoning here just has it backwards.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — the conclusion was right and the reasoning was wrong, which is the worse kind of comment to leave behind. #[strum(serialize_all = "snake_case")] (connector_types.rs:60) governs the derived Display alongside EnumString, which as you say is precisely why get_connector_name() yields adyen and the seeded key resolves.

What strum doesn't reach is serde, and that's the actual reason for the manual route: the connector enums carry no #[serde(rename_all)], so their serde derive would demand Adyen, while TOML keys and the config crate's env-var keys both arrive lowercased. Comment rewritten to say that.

Fixed in d2eeb6a.

Comment thread config/production.toml Outdated
Comment on lines +186 to +187
[connector_response_masking]
enabled = true

@JeevaRamu0104 JeevaRamu0104 Aug 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

enabled = true here and in sandbox.toml, gated independently of return_raw_connector_data, means the masked view reaches callers from the first deploy — which makes the bypasses I've flagged on connector_response_masking.rs live rather than latent. Can we default it false until those are closed and covered? The struct Default is already false, so it's config-only.

The comment block above also promises PAN/CVV/expiry stay masked regardless — true for keyed fields, not for the unkeyed paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking it. enabled = false in both sandbox.toml and production.toml, development.toml stays true. Config-only, as you note — the struct Default was already false.

I'd have argued the containment case is weaker now that the bypasses are closed in this same PR, but that's the wrong way round: the reason to ship it off is that turning it on returns connector response bytes to the caller, so a deployment should opt in once it has chosen its key lists — independent of whether any particular bypass is open. Added that as the comment above the flag.

On the comment block — you're right that it was a claim about keyed fields dressed up as an unconditional one. Rewritten to say what naming a key actually grants, and to name the unkeyed case explicitly rather than leave it implied:

# Naming a key here reveals only that key's own value: an object below it is re-decided key by
# key, and an array below it stays masked, since its elements have no key you could name.
# A key whose name looks like a full PAN, CVV, expiry or credential stays masked even if listed.
# Truncated values (cardSummary, last4, cardBin) are not covered — name them if needed.
# A body that is not JSON, XML or form-encoded has no keys to gate on, so it is replaced
# wholesale by a stub carrying only its size.

Applied to all three config files. Fixed in 52de180.

shuklatushar226 and others added 2 commits August 7, 2026 02:41
Every value is masked unless the connector's allowlist names its key. Four paths
reached a value with no key in scope and defaulted to revealing it.

- `mask_json` seeded the root `mask: false`, so a body with nothing to gate on went
  out in the clear: `["4111111111111111"]` and a bare `"4111111111111111"` were
  returned verbatim. Seeded `true`; an object re-decides per key immediately, so
  nothing correctly revealed today changes.

- Array elements inherited the parent key's decision, so one allowlisted key
  revealed every scalar beneath it — `{"success":["4111111111111111"]}` with
  `success` listed. This contradicted the invariant the object arm upholds:
  elements carry no key, so an operator can never name one. Elements now always
  mask.

- `detect` falls through to `Format::Form` for anything not JSON/XML-shaped, and
  `serde_urlencoded` never fails on arbitrary bytes — a segment with no `=` parses
  as `(whole_segment, "")`. Since keys are emitted verbatim by design, a
  `text/plain` decline message came back out as its own key:

      "Transaction declined for card 4111111111111111"
        -> "Transaction+declined+for+card+4111111111111111=***"

  The `{"_format":"unparsable"}` stub was therefore unreachable on this branch.
  `is_pair_shaped` now gates `mask_form`, which sends non-form bodies to the stub.
  Placed in `mask_form` rather than `detect` so a *declared*
  `x-www-form-urlencoded` body that isn't pair-shaped is caught too.

- `mask_xml`'s catch-all forwarded `Comment`, `PI` and `DocType` verbatim, so a
  gateway echoing the request into a comment leaked it. `Decl` still passes through
  (version/encoding), a comment keeps its presence but not its content, and the
  default is now to drop rather than forward — so a variant a future `quick-xml`
  adds fails closed.

Restores the test module dropped in b4431bc, ported to the current API (`Box<str>`
keys, no `max_bytes`, `&str` connector name) and extended to cover each bypass
above plus the invariants that already held. All 11 new tests fail on the parent
commit. Also corrects the `deserialize_connector_keys` doc comment: strum's
`snake_case` governs `Display` as well as `FromStr`; what it does not reach is
serde, which is the actual reason for the manual route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment

`enabled = true` in sandbox.toml and production.toml put the feature live on the
first deploy, with no chance to review the key lists first. Turning it on returns
connector response bytes to the caller, so each deployment should opt in. Both go
`false`, matching the struct `Default`; development.toml stays `true` so the path
is exercised. Config-only.

Also corrects the comment block above the lists. It promised that PAN, CVV, expiry
and credentials "stay masked regardless of what is listed here", which is a claim
about keyed fields only. It now states what naming a key actually grants — that
key's own value, not the subtree beneath it — and that a body matching no
structured format is replaced by a stub carrying only its size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@shuklatushar226 shuklatushar226 changed the title feat(core): add unmasked_connector_response with per-connector key allowlist feat(core): add masked_connector_response with per-connector key allowlist Aug 6, 2026
@AmitsinghTanwar007

Copy link
Copy Markdown
Contributor

hey @shuklatushar226 one more thing use patch ignore ( if this feature has to be not supported at runtime because then user can use config override on this)

shuklatushar226 and others added 3 commits August 7, 2026 15:25
A runtime `enabled` flag can be flipped by a misconfiguration, an env var or a
config patch. This code turns raw gateway bytes into a string handed back to the
caller, so the guarantee should be structural: a build that does not opt in does
not contain it.

Adds `connector-response-masking`, off by default, chained
domain_types -> external-services -> ucs_env -> grpc-server. Under the gate: the
`connector_response_masking` module (with quick-xml and serde_urlencoded, which
nothing else in domain_types uses, demoted to optional deps), the `Config`
section, the `EventProcessingParams` field, and
`record_masked_connector_response` with its two call sites.

The runtime flag is kept alongside it — the build decides whether the code
exists, config still lets an operator switch it off without a redeploy.

The boundary stops at the proto: prost generates the field unconditionally, so
it and the inert `Option<String>` on the flow-data structs stay in every build
and are simply always unset when the feature is off.

The release image is unaffected: the Dockerfile already pins its feature list,
so it excludes masking by construction. CI's nextest invocations pass the
feature so the module's 40 unit tests keep running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`log_to_span` promised the caller could be sent `masked_connector_response`
"without a copy being retained in our own logs". It did not hold: with the flag
off, the masked view still reached the logs 3 times per request.

Unlike its sibling `raw_connector_response` — declared `SecretString`, which
build.rs maps to `hyperswitch_masking::Secret<String>` whose `Debug` prints a
type stub — this field is a plain `string`, so it has no type-level masking to
fall back on and every generic response log printed it in full.

Two sinks carried it, both in grpc-server::utils:

  - `response_body`, recorded via `field::debug` of the whole response struct.
  - the gRPC event payload, which `emit_event_with_config` logs as JSON on every
    request regardless of `events.enabled`. This one is the only sink for the
    payout endpoints, which carry no `#[tracing::instrument]` at all.

Both now go through `response_for_logging`, which serializes with
`masked_serialize` — matching what the request side already does — and drops the
field while the flag is off. When the flag is on, behaviour is unchanged.

`response_body` consequently changes from Rust-`Debug` to masked JSON: removing
one key requires a structured value. That aligns it with `request_body` on the
same golden log line, and means response logging now honours `Secret` masking
through serde rather than relying on each field's `Debug`.

Verified against the Adyen sandbox: with log_to_span off the field is absent from
every log line while still returned to the caller (1379 bytes), including on the
4xx path; with it on, the previous shape is preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Euler needs the masked connector reply off the event stream, not only on the
gRPC response. Nothing reached Kafka before: the previous commit stripped the
field from the `Event` itself to keep it out of the logs, which removed it from
the published payload too.

That was the wrong cut. `emit_event_with_config` builds one payload and sends it
to both `tracing::info!` and `publish_event_to_kafka`, so keeping a field out of
the logs by removing it from the event necessarily withholds it from consumers.
The two copies have to diverge instead.

  - Attach the masked view to the connector-call event in
    `record_masked_connector_response`, where it is produced, next to the request
    and response it describes. `additional_fields` is flattened, so it lands as a
    top-level `masked_connector_response` key on the `connector_events` topic.
    Attached unconditionally: delivery is the point of the field and must not
    depend on whether we log it.
  - Add `emit_event_with_config_redacting`, which omits given keys from the
    logged copy while publishing the payload intact. `emit_event_with_config`
    delegates to it, so no existing call site changes.
  - Removal is recursive. The key sits at a different depth on each event — top
    level on the connector event, under `response_data` on the gRPC event, and
    nested again inside `event_content.content` for webhook responses. This also
    closes a latent gap in the previous commit, whose top-level-only strip would
    have missed the nested case; nothing leaked only because the webhook path
    hard-codes the field to None.

`log_to_span` therefore returns to meaning what its name says: UCS logs only. It
no longer decides what downstream consumers receive, and the doc comment now
says so — containment is within UCS, not end to end, since the published record
is a retained copy we no longer control.

Verified against the Adyen sandbox: with log_to_span off the field appears in no
log line yet is still returned to the caller; with it on, the connector event
carries it at top level. Six unit tests cover recursive removal, the borrow-when-
unmatched path, and the property this rests on — that redaction leaves the
published value untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants